Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughSupabase 연동 신규 훅 Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant LikeButton
participant usePostLike
participant Supabase
participant Browser
User->>LikeButton: 클릭
LikeButton->>usePostLike: toggleLike()
alt 현재 liked == false
usePostLike->>Browser: getOrCreateViewerId()
usePostLike->>Supabase: insert post_likes (post_id, viewer_id)
alt insert 성공
Supabase-->>usePostLike: success
usePostLike->>LikeButton: liked=true, count++
else 중복키(23505)
Supabase-->>usePostLike: duplicate error
usePostLike->>usePostLike: liked=true, refreshCount()
else 다른 에러
Supabase-->>usePostLike: error
usePostLike->>LikeButton: rollback, error
end
else 현재 liked == true
usePostLike->>Supabase: delete from post_likes (post_id, viewer_id)
Supabase-->>usePostLike: success
usePostLike->>LikeButton: liked=false, count--
end
usePostLike->>LikeButton: loading=false 업데이트
LikeButton->>User: UI 반영
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~22 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 3❌ Failed checks (2 warnings, 1 inconclusive)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
📜 Recent review detailsConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro 📒 Files selected for processing (6)
💤 Files with no reviewable changes (5)
🧰 Additional context used🧬 Code graph analysis (1)src/app/(layout)/posts/[slug]/_components/post-actions/LikeButton.tsx (1)
🔇 Additional comments (1)
✏️ Tip: You can disable this entire section by setting Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/app/(layout)/posts/[slug]/_components/PostActions.tsx (1)
4-11: 타입 정의 들여쓰기 불일치
post속성의 들여쓰기가 다른 속성들과 일관되지 않습니다. 기능에는 문제없지만 가독성을 위해 정리하면 좋겠습니다.✨ 제안하는 수정
interface PostActionsProps { variant?: "desktop" | "mobile"; title: string; thumbnail?: string; - post: { - slug: string - }; + post: { + slug: string; + }; }
🤖 Fix all issues with AI agents
In `@src/hooks/usePostLike.ts`:
- Around line 26-63: There’s a potential race/clarity issue splitting viewerId
initialization and like fetching into two useEffect blocks; combine them into a
single useEffect so you set viewerIdRef.current = getOrCreateViewerId() before
calling fetchLikeState, then run the fetch logic (previously in fetchLikeState)
using viewerIdRef.current (no fallback to getOrCreateViewerId inside the fetch)
and early-return if no viewerId or postId; keep the effect dependent on postId
and ensure you still set loading, count and liked as before (references:
viewerIdRef, getOrCreateViewerId, fetchLikeState, postId).
- Around line 81-119: toggleLike currently waits for the server before updating
UI and never sets a loading flag; implement optimistic UI by immediately
toggling the local state and count at the start of toggleLike, set a loading
state (e.g., setLoading(true)) to prevent double clicks, then perform the
supabase insert/delete calls; on server error rollback the local state/count
(use the prior liked and count values captured before the optimistic update),
handle duplicate-key error by setting liked=true and calling refreshCount(), and
always setLoading(false) in a finally block; refer to toggleLike, setLiked,
setCount, loading/setLoading, viewerIdRef, refreshCount, and isDuplicateKeyError
when making these changes.
In `@src/lib/supabase/viewerId.ts`:
- Around line 6-16: Wrap all accesses to window.localStorage in try/catch in the
viewer id initialization so exceptions (e.g., Safari private mode) don't break
the function: when reading VIEWER_ID_KEY, if localStorage.getItem throws, fall
back to an in-memory id variable; when generating id (using
window.crypto?.randomUUID or Date.now() fallback) still assign to that in-memory
variable and attempt to set localStorage inside its own try/catch so failures to
set are ignored; update references to id (the variable created in this block) so
the function returns/uses the id regardless of localStorage availability.
📜 Review details
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
📒 Files selected for processing (7)
src/app/(layout)/(shell)/_components/layout/SiteHeader.tsxsrc/app/(layout)/posts/[slug]/_components/PostActions.tsxsrc/app/(layout)/posts/[slug]/_components/post-actions/LikeButton.tsxsrc/app/(layout)/posts/[slug]/_components/post-actions/ShareButton.tsxsrc/app/(layout)/posts/[slug]/page.tsxsrc/hooks/usePostLike.tssrc/lib/supabase/viewerId.ts
🧰 Additional context used
🧬 Code graph analysis (4)
src/app/(layout)/posts/[slug]/_components/PostActions.tsx (2)
src/app/(layout)/posts/[slug]/_components/post-actions/LikeButton.tsx (1)
LikeButton(12-57)src/app/(layout)/posts/[slug]/_components/post-actions/ShareButton.tsx (1)
ShareButton(12-50)
src/hooks/usePostLike.ts (2)
src/lib/supabase/viewerId.ts (1)
getOrCreateViewerId(3-19)src/lib/supabase/client.ts (1)
supabase(3-6)
src/app/(layout)/posts/[slug]/_components/post-actions/LikeButton.tsx (1)
src/hooks/usePostLike.ts (1)
usePostLike(19-127)
src/app/(layout)/posts/[slug]/page.tsx (1)
src/app/(layout)/posts/[slug]/_components/PostActions.tsx (1)
PostActions(13-36)
🔇 Additional comments (7)
src/app/(layout)/(shell)/_components/layout/SiteHeader.tsx (1)
41-42: LGTM!다크 모드 스타일링 변경이 적절해 보입니다.
bg-foreground/7토큰 사용과 패딩 조정이 다른 컴포넌트(ShareButton, LikeButton)의 스타일링 방향과 일관성을 유지하고 있습니다.Also applies to: 48-50
src/app/(layout)/posts/[slug]/_components/post-actions/ShareButton.tsx (1)
38-44: LGTM!
bg-neutral-100에서bg-background토큰으로 변경하여 디자인 시스템 일관성이 개선되었습니다.src/app/(layout)/posts/[slug]/page.tsx (1)
75-75: LGTM!
post객체를PostActions에 전달하여LikeButton이postId를 사용할 수 있게 되었습니다.PostActionsProps가{ slug: string }타입만 요구하므로, 전체post객체를 전달해도 TypeScript 구조적 타이핑에 의해 호환됩니다.src/app/(layout)/posts/[slug]/_components/PostActions.tsx (1)
13-35: LGTM!
post.slug를LikeButton의postId로 전달하는 구조가 명확합니다. mobile과 desktop 두 variant 모두에서 일관되게 처리되고 있습니다.PR 목표인 좋아요 기능의 핵심 흐름(
PostActions→LikeButton→usePostLike)이 잘 연결되어 있습니다.src/app/(layout)/posts/[slug]/_components/post-actions/LikeButton.tsx (1)
23-55: 버튼 UI 및 접근성 구현이 잘 되어 있습니다.
aria-pressed사용으로 스크린 리더 지원loading상태에서 버튼 비활성화- 다크 모드 스타일링 적용
- 애니메이션에
motion-safe적용으로 접근성 고려src/hooks/usePostLike.ts (2)
15-17:isDuplicateKeyError헬퍼 함수 구현이 적절합니다.PostgreSQL 에러 코드
23505(unique_violation)를 체크하여 중복 좋아요를 안전하게 처리하고 있습니다.
8-13: 타입 정의가 명확합니다.
UsePostLikeResult타입이 훅의 반환값을 명확하게 정의하고 있어 타입 안전성이 좋습니다.
✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.
| const handleClick = () => { | ||
| setIsLiked((v) => !v); | ||
|
|
||
| toggleLike(); | ||
| setIsPopping(true); | ||
| window.setTimeout(() => setIsPopping(false), 180); | ||
| }; |
There was a problem hiding this comment.
toggleLike()가 비동기 함수인데 await 없이 호출되고 있습니다.
현재 구현에서 두 가지 문제가 있습니다:
-
중복 클릭 방지 미흡:
toggleLike()가 완료되기 전에 버튼을 다시 클릭할 수 있습니다.usePostLike훅의loading상태가toggleLike실행 중에는true로 설정되지 않아서 버튼이 비활성화되지 않습니다. -
애니메이션 타이밍: 요청 성공/실패와 관계없이 애니메이션이 즉시 실행됩니다. 이슈
#21의요구사항(optimistic UI + rollback on failure)을 고려하면, 실패 시 애니메이션 롤백도 고려해야 합니다.
🔧 제안하는 수정안
+ const [isPending, setIsPending] = useState(false);
+
- const handleClick = () => {
- toggleLike();
- setIsPopping(true);
- window.setTimeout(() => setIsPopping(false), 180);
+ const handleClick = async () => {
+ if (isPending) return;
+ setIsPending(true);
+ setIsPopping(true);
+ window.setTimeout(() => setIsPopping(false), 180);
+ try {
+ await toggleLike();
+ } finally {
+ setIsPending(false);
+ }
};그리고 버튼의 disabled 속성도 업데이트:
- disabled={loading}
+ disabled={loading || isPending}| useEffect(() => { | ||
| viewerIdRef.current = getOrCreateViewerId(); | ||
| }, []); | ||
|
|
||
| useEffect(() => { | ||
| if (!postId) return; | ||
|
|
||
| const fetchLikeState = async () => { | ||
| const viewerId = viewerIdRef.current ?? getOrCreateViewerId(); | ||
| if (!viewerId) { | ||
| setLoading(false); | ||
| return; | ||
| } | ||
|
|
||
| const { | ||
| data, | ||
| count: total, | ||
| error, | ||
| } = await supabase | ||
| .from("post_likes") | ||
| .select("viewer_id", { count: "exact" }) | ||
| .eq("post_id", postId); | ||
|
|
||
| if (error) { | ||
| console.error("좋아요 조회 실패:", error); | ||
| setLoading(false); | ||
| return; | ||
| } | ||
|
|
||
| const rows = data ?? []; | ||
|
|
||
| setCount(total ?? rows.length); | ||
| setLiked(rows.some((row) => row.viewer_id === viewerId)); | ||
| setLoading(false); | ||
| }; | ||
|
|
||
| void fetchLikeState(); | ||
| }, [postId]); |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
두 개의 useEffect 간 경쟁 상태(Race Condition) 가능성이 있습니다.
첫 번째 useEffect(Line 26-28)에서 viewerIdRef를 설정하고, 두 번째 useEffect(Line 30-63)에서 이를 사용합니다. React의 useEffect는 선언 순서대로 실행되지만, 동일 렌더 사이클에서 실행되므로 첫 번째 effect가 완료된 후 두 번째가 실행되는 것이 보장됩니다.
다만 Line 34에서 viewerIdRef.current ?? getOrCreateViewerId() 폴백이 있어 실질적인 문제는 없지만, 두 개의 effect로 분리할 필요 없이 하나로 합치는 것이 더 명확합니다.
♻️ 단일 useEffect로 통합 제안
- useEffect(() => {
- viewerIdRef.current = getOrCreateViewerId();
- }, []);
-
useEffect(() => {
if (!postId) return;
+
+ viewerIdRef.current = getOrCreateViewerId();
const fetchLikeState = async () => {
- const viewerId = viewerIdRef.current ?? getOrCreateViewerId();
+ const viewerId = viewerIdRef.current;
if (!viewerId) {
setLoading(false);
return;
}
// ... rest of the code
};
void fetchLikeState();
}, [postId]);🤖 Prompt for AI Agents
In `@src/hooks/usePostLike.ts` around lines 26 - 63, There’s a potential
race/clarity issue splitting viewerId initialization and like fetching into two
useEffect blocks; combine them into a single useEffect so you set
viewerIdRef.current = getOrCreateViewerId() before calling fetchLikeState, then
run the fetch logic (previously in fetchLikeState) using viewerIdRef.current (no
fallback to getOrCreateViewerId inside the fetch) and early-return if no
viewerId or postId; keep the effect dependent on postId and ensure you still set
loading, count and liked as before (references: viewerIdRef,
getOrCreateViewerId, fetchLikeState, postId).
| useEffect(() => { | ||
| if (!postId) return; | ||
|
|
||
| const fetchLikeState = async () => { | ||
| const viewerId = viewerIdRef.current ?? getOrCreateViewerId(); | ||
| if (!viewerId) { | ||
| setLoading(false); | ||
| return; | ||
| } | ||
|
|
||
| const { | ||
| data, | ||
| count: total, | ||
| error, | ||
| } = await supabase | ||
| .from("post_likes") | ||
| .select("viewer_id", { count: "exact" }) | ||
| .eq("post_id", postId); | ||
|
|
||
| if (error) { | ||
| console.error("좋아요 조회 실패:", error); | ||
| setLoading(false); | ||
| return; | ||
| } | ||
|
|
||
| const rows = data ?? []; | ||
|
|
||
| setCount(total ?? rows.length); | ||
| setLiked(rows.some((row) => row.viewer_id === viewerId)); | ||
| setLoading(false); | ||
| }; | ||
|
|
||
| void fetchLikeState(); | ||
| }, [postId]); |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
비동기 작업의 cleanup이 없어 메모리 누수 가능성이 있습니다.
컴포넌트가 언마운트된 후에도 fetchLikeState의 setState 호출이 발생할 수 있습니다. 빠른 페이지 이동 시 경고가 발생할 수 있습니다.
♻️ AbortController 또는 cleanup flag 추가 제안
useEffect(() => {
if (!postId) return;
+ let isMounted = true;
const fetchLikeState = async () => {
const viewerId = viewerIdRef.current ?? getOrCreateViewerId();
if (!viewerId) {
+ if (isMounted) setLoading(false);
- setLoading(false);
return;
}
const { data, count: total, error } = await supabase
.from("post_likes")
.select("viewer_id", { count: "exact" })
.eq("post_id", postId);
+ if (!isMounted) return;
+
if (error) {
console.error("좋아요 조회 실패:", error);
setLoading(false);
return;
}
const rows = data ?? [];
setCount(total ?? rows.length);
setLiked(rows.some((row) => row.viewer_id === viewerId));
setLoading(false);
};
void fetchLikeState();
+
+ return () => {
+ isMounted = false;
+ };
}, [postId]);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| useEffect(() => { | |
| if (!postId) return; | |
| const fetchLikeState = async () => { | |
| const viewerId = viewerIdRef.current ?? getOrCreateViewerId(); | |
| if (!viewerId) { | |
| setLoading(false); | |
| return; | |
| } | |
| const { | |
| data, | |
| count: total, | |
| error, | |
| } = await supabase | |
| .from("post_likes") | |
| .select("viewer_id", { count: "exact" }) | |
| .eq("post_id", postId); | |
| if (error) { | |
| console.error("좋아요 조회 실패:", error); | |
| setLoading(false); | |
| return; | |
| } | |
| const rows = data ?? []; | |
| setCount(total ?? rows.length); | |
| setLiked(rows.some((row) => row.viewer_id === viewerId)); | |
| setLoading(false); | |
| }; | |
| void fetchLikeState(); | |
| }, [postId]); | |
| useEffect(() => { | |
| if (!postId) return; | |
| let isMounted = true; | |
| const fetchLikeState = async () => { | |
| const viewerId = viewerIdRef.current ?? getOrCreateViewerId(); | |
| if (!viewerId) { | |
| if (isMounted) setLoading(false); | |
| return; | |
| } | |
| const { | |
| data, | |
| count: total, | |
| error, | |
| } = await supabase | |
| .from("post_likes") | |
| .select("viewer_id", { count: "exact" }) | |
| .eq("post_id", postId); | |
| if (!isMounted) return; | |
| if (error) { | |
| console.error("좋아요 조회 실패:", error); | |
| setLoading(false); | |
| return; | |
| } | |
| const rows = data ?? []; | |
| setCount(total ?? rows.length); | |
| setLiked(rows.some((row) => row.viewer_id === viewerId)); | |
| setLoading(false); | |
| }; | |
| void fetchLikeState(); | |
| return () => { | |
| isMounted = false; | |
| }; | |
| }, [postId]); |
| const toggleLike = async () => { | ||
| const viewerId = viewerIdRef.current ?? getOrCreateViewerId(); | ||
| if (!viewerId) return; | ||
|
|
||
| if (liked) { | ||
| const { error } = await supabase | ||
| .from("post_likes") | ||
| .delete() | ||
| .eq("post_id", postId) | ||
| .eq("viewer_id", viewerId); | ||
|
|
||
| if (error) { | ||
| console.error("좋아요 취소 실패:", error); | ||
| return; | ||
| } | ||
|
|
||
| setLiked(false); | ||
| setCount((prev) => Math.max(0, prev - 1)); | ||
| return; | ||
| } | ||
|
|
||
| const { error } = await supabase.from("post_likes").insert({ | ||
| post_id: postId, | ||
| viewer_id: viewerId, | ||
| }); | ||
|
|
||
| if (error) { | ||
| if (isDuplicateKeyError(error)) { | ||
| setLiked(true); | ||
| await refreshCount(); | ||
| } else { | ||
| console.error("좋아요 추가 실패:", error); | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| setLiked(true); | ||
| setCount((prev) => prev + 1); | ||
| }; |
There was a problem hiding this comment.
Optimistic UI가 구현되지 않았습니다 (이슈 #21 요구사항).
이슈 #21에서 요구하는 "optimistic UI: update UI immediately on click and rollback if server fails"가 현재 구현되어 있지 않습니다. 현재는 서버 응답을 기다린 후에만 UI가 업데이트됩니다.
또한 toggleLike 실행 중 loading 상태가 true로 설정되지 않아서 컴포넌트에서 중복 클릭 방지가 어렵습니다.
🔧 Optimistic UI 및 loading 상태 추가 제안
const toggleLike = async () => {
const viewerId = viewerIdRef.current ?? getOrCreateViewerId();
if (!viewerId) return;
+ // Optimistic update
+ const prevLiked = liked;
+ const prevCount = count;
+
+ setLiked(!liked);
+ setCount((prev) => liked ? Math.max(0, prev - 1) : prev + 1);
+ setLoading(true);
if (liked) {
const { error } = await supabase
.from("post_likes")
.delete()
.eq("post_id", postId)
.eq("viewer_id", viewerId);
if (error) {
console.error("좋아요 취소 실패:", error);
+ // Rollback on failure
+ setLiked(prevLiked);
+ setCount(prevCount);
+ setLoading(false);
return;
}
- setLiked(false);
- setCount((prev) => Math.max(0, prev - 1));
+ setLoading(false);
return;
}
const { error } = await supabase.from("post_likes").insert({
post_id: postId,
viewer_id: viewerId,
});
if (error) {
if (isDuplicateKeyError(error)) {
setLiked(true);
await refreshCount();
} else {
console.error("좋아요 추가 실패:", error);
+ // Rollback on failure
+ setLiked(prevLiked);
+ setCount(prevCount);
}
+ setLoading(false);
return;
}
- setLiked(true);
- setCount((prev) => prev + 1);
+ setLoading(false);
};🤖 Prompt for AI Agents
In `@src/hooks/usePostLike.ts` around lines 81 - 119, toggleLike currently waits
for the server before updating UI and never sets a loading flag; implement
optimistic UI by immediately toggling the local state and count at the start of
toggleLike, set a loading state (e.g., setLoading(true)) to prevent double
clicks, then perform the supabase insert/delete calls; on server error rollback
the local state/count (use the prior liked and count values captured before the
optimistic update), handle duplicate-key error by setting liked=true and calling
refreshCount(), and always setLoading(false) in a finally block; refer to
toggleLike, setLiked, setCount, loading/setLoading, viewerIdRef, refreshCount,
and isDuplicateKeyError when making these changes.
| let id = window.localStorage.getItem(VIEWER_ID_KEY); | ||
|
|
||
| if (!id) { | ||
| if (window.crypto?.randomUUID) { | ||
| id = window.crypto.randomUUID(); | ||
| } else { | ||
| id = `${Date.now()}-${Math.random().toString(16).slice(2)}`; | ||
| } | ||
|
|
||
| window.localStorage.setItem(VIEWER_ID_KEY, id); | ||
| } |
There was a problem hiding this comment.
localStorage 접근 시 예외 처리 고려 필요
일부 브라우저(Safari 프라이빗 모드 등)에서 localStorage 접근 시 예외가 발생할 수 있습니다. 현재 코드는 이 경우 전체 함수가 실패하게 됩니다.
🛠️ 제안하는 수정
export function getOrCreateViewerId(): string | null {
if (typeof window === "undefined") return null;
- let id = window.localStorage.getItem(VIEWER_ID_KEY);
-
- if (!id) {
- if (window.crypto?.randomUUID) {
- id = window.crypto.randomUUID();
- } else {
- id = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
+ try {
+ let id = window.localStorage.getItem(VIEWER_ID_KEY);
+
+ if (!id) {
+ if (window.crypto?.randomUUID) {
+ id = window.crypto.randomUUID();
+ } else {
+ id = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
+ }
+
+ window.localStorage.setItem(VIEWER_ID_KEY, id);
}
- window.localStorage.setItem(VIEWER_ID_KEY, id);
+ return id;
+ } catch {
+ // localStorage 접근 불가 시 (프라이빗 모드 등)
+ return null;
}
-
- return id;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| let id = window.localStorage.getItem(VIEWER_ID_KEY); | |
| if (!id) { | |
| if (window.crypto?.randomUUID) { | |
| id = window.crypto.randomUUID(); | |
| } else { | |
| id = `${Date.now()}-${Math.random().toString(16).slice(2)}`; | |
| } | |
| window.localStorage.setItem(VIEWER_ID_KEY, id); | |
| } | |
| export function getOrCreateViewerId(): string | null { | |
| if (typeof window === "undefined") return null; | |
| try { | |
| let id = window.localStorage.getItem(VIEWER_ID_KEY); | |
| if (!id) { | |
| if (window.crypto?.randomUUID) { | |
| id = window.crypto.randomUUID(); | |
| } else { | |
| id = `${Date.now()}-${Math.random().toString(16).slice(2)}`; | |
| } | |
| window.localStorage.setItem(VIEWER_ID_KEY, id); | |
| } | |
| return id; | |
| } catch { | |
| // localStorage 접근 불가 시 (프라이빗 모드 등) | |
| return null; | |
| } | |
| } |
🤖 Prompt for AI Agents
In `@src/lib/supabase/viewerId.ts` around lines 6 - 16, Wrap all accesses to
window.localStorage in try/catch in the viewer id initialization so exceptions
(e.g., Safari private mode) don't break the function: when reading
VIEWER_ID_KEY, if localStorage.getItem throws, fall back to an in-memory id
variable; when generating id (using window.crypto?.randomUUID or Date.now()
fallback) still assign to that in-memory variable and attempt to set
localStorage inside its own try/catch so failures to set are ignored; update
references to id (the variable created in this block) so the function
returns/uses the id regardless of localStorage availability.
요약
post_likes테이블 + UNIQUE 제약 + RLS 정책 구성변경 내용
UI/컴포넌트
PostLikeButton) 추가로직/유틸
post_likes(post_id, viewer_id)UNIQUE 제약 추가supabase/client.ts)viewer_id생성 (viewerId.ts)usePostLike(postId)구현23505) 처리문서/설정
스크린샷/동영상 (선택)
테스트
pnpm typecheck,pnpm lint)관련 이슈
close #21
Summary by CodeRabbit
릴리스 노트
새로운 기능
스타일
잡무(Chores)
✏️ Tip: You can customize this high-level summary in your review settings.